SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
11.0 KB · 233 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { requireUser } from '@/lib/auth/session';5import { getCollectionDetail, listCollections } from '@/lib/account/queries';6import { getDisplay } from '@/lib/account/display';7import { deleteCollectionAction, toggleCollectionPublicAction } from '@/lib/account/actions';8import { PageHeader, btnSecondary, btnDanger, btnPrimary } from '@/components/account/page-header';9import { SummaryStats, AllocationCards, ValueSourceBadge } from '@/components/account/portfolio-widgets';10import { LineChart } from '@/components/account/charts';11import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';12import { fmtDate, fmtPct, confidenceLabel } from '@/lib/format';13import { EditCollectionForm } from '../create-form';14import { AddItemForm } from './add-item-form';1516export const metadata: Metadata = { title: 'Collection', robots: { index: false } };1718export default async function CollectionPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<Record<string, string | undefined>> }) {19  const { id } = await params;20  const sp = await searchParams;21  const u = await requireUser(`/collections/${id}`);22  const detail = await getCollectionDetail(u.id, id);23  if (!detail) notFound();24  const d = await getDisplay();25  const { collection: c, summary: s, items, history } = detail;26  const others = (await listCollections(u.id)).filter((x) => x.collection.id !== id);27  const sort = sp.sort ?? 'value';28  const rows = [...s.items].sort((a, b) => {29    if (sort === 'gain') return (b.gainPct ?? -Infinity) - (a.gainPct ?? -Infinity);30    if (sort === 'name') return a.title.localeCompare(b.title);31    if (sort === 'recent') return (items.find((i) => i.id === b.id)?.createdAt.getTime() ?? 0) - (items.find((i) => i.id === a.id)?.createdAt.getTime() ?? 0);32    return (b.valueUsd ?? -1) - (a.valueUsd ?? -1);33  });34  const publicUrl = c.isPublic && u.handle && c.publicSlug ? `/u/${u.handle}/${c.publicSlug}` : null;3536  return (37    <>38      <nav className="mb-2 text-xs text-muted">39        <Link href="/collections" className="hover:text-fg">40          Collections41        </Link>{' '}42        / <span className="text-fg">{c.name}</span>43      </nav>44      <PageHeader45        title={46          <span className="flex items-center gap-2">47            {c.color ? <span className="h-3 w-3 rounded-full" style={{ background: c.color }} /> : null}48            {c.name}49            {c.isPublic ? <Badge tone="index">public</Badge> : <Badge tone="neutral">private</Badge>}50          </span>51        }52        description={c.description ?? `${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · created ${fmtDate(c.createdAt)}`}53        actions={54          <>55            <a href="#add" className={btnPrimary}>56              Add item57            </a>58            <Link href={`/collections/${id}/import`} className={btnSecondary}>59              Import CSV60            </Link>61            <Link href={`/collections/${id}/insurance`} className={btnSecondary}>62              Insurance schedule63            </Link>64            <a href={`/api/account/collections/${id}/export?format=csv`} className={btnSecondary}>65              CSV66            </a>67            <a href={`/api/account/collections/${id}/export?format=json`} className={btnSecondary}>68              JSON69            </a>70          </>71        }72      />73      <SummaryStats s={s} d={d} className="mb-5" />74      {s.valuedCount > 0 ? (75        <div className="mb-5 space-y-4">76          <Card>77            <CardHeader title="Value history" subtitle={history.length ? `${history.length} daily snapshots (value vs cost basis)` : 'Snapshots are recorded daily by the RareIndex worker'} />78            <div className="p-4">79              <LineChart series={[{ name: 'Value', points: history.map((h) => ({ date: String(h.date), value: h.valueUsd * d.rate })) }, { name: 'Cost basis', points: history.map((h) => ({ date: String(h.date), value: h.costBasisUsd * d.rate })), color: 'var(--ri-fg-subtle)', dashed: true }]} height={180} formatY={(v) => d.money(v / d.rate, { compact: true })} />80            </div>81          </Card>82          <AllocationCards s={s} d={d} />83        </div>84      ) : null}8586      <Card className="mb-5">87        <CardHeader88          title="Items"89          subtitle={`${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · ${s.unitCount} unit${s.unitCount === 1 ? '' : 's'}`}90          action={91            <div className="flex gap-1 text-xs">92              {[93                ['value', 'Value'],94                ['gain', 'Return'],95                ['recent', 'Recent'],96                ['name', 'Name'],97              ].map(([k, l]) => (98                <Link key={k} href={`?sort=${k}`} className={`rounded-sm px-2 py-1 ${sort === k ? 'bg-inset text-fg' : 'text-muted hover:text-fg'}`}>99                  {l}100                </Link>101              ))}102            </div>103          }104        />105        {rows.length === 0 ? (106          <EmptyState title="This collection is empty" description="Add an item below by searching the RareIndex asset universe, or import a CSV." />107        ) : (108          <Table>109            <thead>110              <tr>111                <th className={th}>Item</th>112                <th className={th}>Variant</th>113                <th className={`${th} text-right`}>Qty</th>114                <th className={`${th} text-right`}>Cost</th>115                <th className={`${th} text-right`}>Value</th>116                <th className={`${th} text-right`}>Gain</th>117                <th className={`${th} text-right`}>30d</th>118                <th className={th}>Source</th>119                <th className={th}></th>120              </tr>121            </thead>122            <tbody>123              {rows.map((i) => {124                const src = items.find((x) => x.id === i.id)!;125                return (126                  <tr key={i.id} className="hover:bg-sunken">127                    <td className={td}>128                      <div className="flex items-center gap-2.5">129                        {src.photos[0] || src.heroImageUrl ? (130                          // eslint-disable-next-line @next/next/no-img-element131                          <img src={src.photos[0] ?? src.heroImageUrl ?? ''} alt="" className="h-9 w-9 rounded-sm object-cover" />132                        ) : (133                          <span className="h-9 w-9 rounded-sm bg-inset" />134                        )}135                        <div className="min-w-0 max-w-[280px]">136                          <Link href={`/collections/${id}/items/${i.id}`} className="block truncate font-medium hover:underline">137                            {i.title}138                          </Link>139                          <p className="truncate text-[11px] text-subtle">140                            {i.categorySlug.replace(/_/g, ' ')}141                            {i.acquiredAt ? ` · acquired ${i.acquiredAt}` : ''}142                            {src.tags.length ? ` · ${src.tags.join(', ')}` : ''}143                          </p>144                        </div>145                      </div>146                    </td>147                    <td className={`${td} text-xs`}>{src.variantLabel ?? (i.grader ? `${i.grader.toUpperCase()} ${i.grade ?? ''}` : src.condition ?? '—')}</td>148                    <td className={tdNum}>{i.quantity}</td>149                    <td className={tdNum}>{i.costUsd === null ? <span className="text-subtle">—</span> : d.money(i.costUsd)}</td>150                    <td className={tdNum}>151                      {i.valueUsd === null ? <span className="text-subtle" title="No market evidence yet">—</span> : <span title={i.confidence !== null ? `Confidence ${confidenceLabel(i.confidence)}` : undefined}>{d.money(i.valueUsd)}</span>}152                    </td>153                    <td className={tdNum}>154                      <Delta value={i.gainPct} />155                    </td>156                    <td className={tdNum}>157                      <span className="text-xs">{fmtPct(i.change30d)}</span>158                    </td>159                    <td className={td}>160                      <ValueSourceBadge item={i} />161                    </td>162                    <td className={td}>163                      <Link href={`/collections/${id}/items/${i.id}`} className="text-xs text-muted hover:text-fg">164                        Edit165                      </Link>166                    </td>167                  </tr>168                );169              })}170            </tbody>171          </Table>172        )}173      </Card>174175      <div id="add" className="mb-5"><Card>176        <CardHeader title="Add an item" subtitle="Search the RareIndex asset universe, then record what you paid. Photos and notes can be added after saving." />177        <div className="p-4">178          <AddItemForm collectionId={id} />179        </div>180      </Card></div>181182      <div className="grid gap-4 lg:grid-cols-2">183        <Card>184          <CardHeader title="Sharing" subtitle="Private by default. Public collections appear on your collector profile with values and photos — never purchase prices." />185          <div className="space-y-3 p-4 text-sm">186            <form action={toggleCollectionPublicAction} className="flex items-center gap-3">187              <input type="hidden" name="collectionId" value={id} />188              <label className="flex items-center gap-2">189                <input type="checkbox" name="public" defaultChecked={c.isPublic} className="h-4 w-4" />190                Make this collection public191              </label>192              <button className={btnSecondary}>Save</button>193            </form>194            {c.isPublic ? (195              publicUrl ? (196                <p className="text-xs text-muted">197                  Public at{' '}198                  <Link href={publicUrl} className="text-fg underline">199                    {publicUrl}200                  </Link>201                </p>202              ) : (203                <p className="text-xs text-alert">204                  Claim a public handle in{' '}205                  <Link href="/account/settings#handle" className="underline">206                    profile settings207                  </Link>{' '}208                  to get a shareable URL.209                </p>210              )211            ) : null}212          </div>213        </Card>214        <Card>215          <CardHeader title="Settings" />216          <div className="space-y-4 p-4">217            <EditCollectionForm collection={{ id: c.id, name: c.name, description: c.description, kind: c.kind, budgetUsd: c.budgetUsd, color: c.color }} />218            <div className="flex items-center justify-between border-t border-border pt-4">219              <p className="text-xs text-muted">220                {others.length ? `Move items to another collection from each item page.` : 'Create another collection to move items between them.'}221              </p>222              <form action={deleteCollectionAction}>223                <input type="hidden" name="collectionId" value={id} />224                <button className={btnDanger}>Delete collection</button>225              </form>226            </div>227          </div>228        </Card>229      </div>230    </>231  );232}233